package org.springframework.xml.xpath.proxy;

import org.apache.log4j.Logger;
import org.springframework.util.Assert;
import org.springframework.xml.xpath.proxy.conversion.Converter;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.namespace.NamespaceContext;
import javax.xml.namespace.QName;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import javax.xml.xpath.XPathFactoryConfigurationException;
import javax.xml.xpath.XPathVariableResolver;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

/**
 * Handles method invocations for the proxy interface.
 *
 * @author Ray Krueger
 */
public class XPathMethodInvocationHandler implements InvocationHandler {

    private static final Logger log = Logger.getLogger(XPathMethodInvocationHandler.class);

    private final Node element;
    private final Converter[] converters;
    private final Map<Method, XPath> methodToXPathCache = new HashMap<Method, XPath>();
    private final Map<Method, Object> noArgMethodResultCache = new HashMap<Method, Object>();
    private NamespaceContextBuilder namespaceContextBuilder = new DefaultNamespaceContextBuilder();

    public XPathMethodInvocationHandler(Class targetInterface, Node element, Converter[] converters) {

        this.converters = (converters == null) ? new Converter[0] : converters;
        this.element = element;

        for (Method method : targetInterface.getMethods()) {
            Assert.isTrue(method.isAnnotationPresent(XPathMethod.class),
                    "@XPathMethod annotation not found on method [" + method + "]");
        }
    }

    public Object invoke(Object proxy, Method method, final Object[] args) throws Throwable {
        log.debug("Invoker called for method [" + method + "]");

        Object result = noArgMethodResultCache.get(method);
        if (result != null) {
            log.debug("Result from no arg method cache being returned");
            return result;
        }

        XPath xpath = methodToXPathCache.get(method);
        if (xpath == null) {
            log.debug("No xpath instance in cache, creating");
            xpath = createXPath(method.getDeclaringClass(), args);
            methodToXPathCache.put(method, xpath);
        } else {
            log.debug("Using xpath instance from cache");
        }

        Class returnType = method.getReturnType();
        Converter<?> converter = findConverter(returnType);

        if (!isSupportedXPathType(returnType) && converter == null) {
            throw new IllegalArgumentException("A converter is required but not registered for method return type [" + returnType + "]. " +
                    "The offending method is [" + method + "].");
        }

        boolean conversionRequired = converter != null;

        QName qname;
        if (conversionRequired) {
            qname = converter.getExpectedInput();
        } else {
            qname = determineExpectedQName(method.getReturnType());
        }

        String expression = getExpressionForMethod(method);

        Object returnValue = evaluateXPath(xpath, expression, qname);
        if (conversionRequired) {
            returnValue = convert(converter, (String) returnValue);
        }

        if (method.getParameterTypes().length == 0) {
            log.debug("Caching no arg method result");
            noArgMethodResultCache.put(method, returnValue);
        }

        return returnValue;
    }

    protected Object evaluateXPath(XPath xpath, String expression, QName qname) throws XPathExpressionException {
        return xpath.evaluate(expression, element, qname);
    }

    protected String getExpressionForMethod(Method method) {
        XPathMethod annotation = method.getAnnotation(XPathMethod.class);
        return annotation.value();
    }

    protected void registerVariableResolver(XPath xpath, final Object[] args) {

        XPathVariableResolver variableResolver = new XPathVariableResolver() {
            public Object resolveVariable(QName qname) {
                for (int i = 0; i < args.length; i++) {
                    Object arg = args[i];
                    if (String.valueOf(i).equals(qname.getLocalPart())) {
                        return arg;
                    }
                }
                return null;
            }
        };

        xpath.setXPathVariableResolver(variableResolver);
    }

    /**
     * Find the proper converter for the given type.
     * Uses caches results to avoid multiple iterations over the converter array.
     *
     * @param cls the type we'd like a converter for.
     * @return Converter found or null
     */
    protected Converter<?> findConverter(Class<?> cls) {

        log.debug("Finding converter for type [" + cls.getName() + "]");
        for (Converter<?> converter : converters) {
            if (converter.supportsOutputType(cls)) {
                log.debug("Found converter [" + converter + "] for type [" + cls.getName() + "]");
                return converter;
            }
        }

        log.debug("No converter found for type [" + cls.getName() + "]");
        return null;
    }

    private XPath createXPath(Class targetInterface, Object[] args) throws XPathFactoryConfigurationException {
        NamespaceContext namespaceContext = namespaceContextBuilder.buildNamespaceContext(targetInterface);
        XPath xpath = XPathFactory.newInstance(XPathConstants.DOM_OBJECT_MODEL).newXPath();
        if (namespaceContext != null) {
            xpath.setNamespaceContext(namespaceContext);
        }
        if (args != null && args.length > 0) {
            registerVariableResolver(xpath, args);
        }
        return xpath;
    }

    protected Object convert(Converter converter, final String returnValue) {
        return converter.convert(returnValue);
    }

    private QName determineExpectedQName(Class<?> returnType) throws XPathExpressionException {

        QName conversionType;
        if (Boolean.class.isAssignableFrom(returnType) || Boolean.TYPE.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.BOOLEAN;
        } else if (Double.class.isAssignableFrom(returnType) || Double.TYPE.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.NUMBER;
        } else if (Node.class.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.NODE;
        } else if (NodeList.class.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.NODESET;
        } else if (String.class.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.STRING;
        } else {
            throw new IllegalArgumentException(returnType + " does not have a mapping to a known XPath return type");
        }

        return conversionType;
    }

    private boolean isSupportedXPathType(Class<?> clazz) {
        return Boolean.class.isAssignableFrom(clazz) || Boolean.TYPE.isAssignableFrom(clazz) ||
                Double.class.isAssignableFrom(clazz) || Double.TYPE.isAssignableFrom(clazz) ||
                Node.class.isAssignableFrom(clazz) || NodeList.class.isAssignableFrom(clazz) ||
                String.class.isAssignableFrom(clazz);
    }

    public void setNamespaceContextBuilder(NamespaceContextBuilder namespaceContextBuilder) {
        this.namespaceContextBuilder = namespaceContextBuilder;
    }
}
